/*
 * Copyright by LunaSec (owned by Refinery Labs, Inc)
 *
 * Licensed under the Business Source License v1.1
 * (the "License"); you may not use this file except in compliance with the
 * License. You may obtain a copy of the License at
 *
 * https://github.com/lunasec-io/lunasec/blob/master/licenses/BSL-LunaTrace.txt
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
import { SeverityNamesOsv } from '@lunatrace/lunatrace-common/build/main';

import { Analysis_Finding_Type_Enum } from '../../hasura-api/generated';
import { notEmpty } from '../../utils/predicates';

import { Graph } from './graph';
import { adjustRelease } from './risk-adjustment/adjust';
import {
  AnalysisResultLocation,
  DependencyChain,
  FolderSettings,
  IgnoredVulnerability,
  Manifest,
  ManifestNode,
  TriagedPackageVulnerability,
  VulnerableEdge,
  VulnerableManifestNode,
  VulnerableRelease,
} from './types';
import {
  createOrMergeVulnerablePackageRelease,
  isReleaseTriviallyUpdateable,
  VulnerabilityProcessor,
} from './vulnerability-processor';

interface NodeReachability {
  reachable: Analysis_Finding_Type_Enum;
  locations: AnalysisResultLocation[];
}

export default class VulnerabilityDependencyTree {
  public readonly graph: Graph;
  private manifestRootNodes: Set<string> = new Set();
  private packageReleaseIdToTriagedVulnerabilities: Map<string, TriagedPackageVulnerability[]> = new Map();
  private nodeIdToNode: Map<string, ManifestNode> = new Map();
  private vulnerableNodeIds: Set<string> = new Set();
  private nodeReachability: Map<string, NodeReachability> = new Map();

  constructor(
    sourceManifests: Array<Manifest>,
    public folderSettings: FolderSettings = [],
    ignoredVulnerabilities?: IgnoredVulnerability[],
    private minimumSeverity?: SeverityNamesOsv
  ) {
    this.graph = new Graph();

    const vulnLoader = new VulnerabilityProcessor(ignoredVulnerabilities || [], minimumSeverity || 'Unknown');

    sourceManifests.forEach((manifest) => {
      const rootNodeId = manifest.manifest_dependency_node?.id;
      if (rootNodeId) {
        this.manifestRootNodes.add(rootNodeId);
      }

      manifest.child_edges_recursive?.forEach((edge) => {
        const node = edge.child;
        const childId = edge.child_id;
        const parentId = edge.parent_id;

        this.nodeIdToNode.set(childId, node);

        const packageReleaseVulnerabilities = vulnLoader.processVulnerabilitiesOnNode(node, manifest.path || 'Unknown');

        // get existing vulnerabilities for a release, and extend them to include the newly built release vulnerabilities
        const existingReleaseVulnerabilities = this.packageReleaseIdToTriagedVulnerabilities.get(node.release_id);
        this.packageReleaseIdToTriagedVulnerabilities.set(
          node.release_id,
          existingReleaseVulnerabilities
            ? existingReleaseVulnerabilities.concat(packageReleaseVulnerabilities)
            : packageReleaseVulnerabilities
        );

        if (packageReleaseVulnerabilities.length > 0) {
          this.vulnerableNodeIds.add(childId); // dupes will be handled by the Set
        }

        /*
         find analysis results proving that a path is "not vulnerable" or "not reachable". in other words, we say a node is
         _not_ reachable _only_ when we have explicit analysis results that say that it is not reachable.
         */
        const latestResults = edge.analysis_results.length > 0 ? edge.analysis_results[0] : null;
        const reachable = latestResults !== null ? latestResults.finding_type : Analysis_Finding_Type_Enum.Unknown;

        const reachability = {
          reachable,
          locations: latestResults?.locations || [],
        };

        this.nodeReachability.set(childId, reachability);

        this.graph.insertEdge(edge.id, parentId, childId);
      });
    });
  }

  private allDependencyPathsToNodeId(nodeId: string): string[][] {
    const chainsToTraverse: string[][] = [];

    this.graph.getParentIds(nodeId).forEach((parent) => chainsToTraverse.push([parent, nodeId]));

    const dependencyChains = [];
    while (chainsToTraverse.length > 0) {
      const chain = chainsToTraverse.pop();
      if (!chain) {
        continue;
      }

      if (this.manifestRootNodes.has(chain[0])) {
        dependencyChains.push(chain);
        continue;
      }
      const parentNodes = this.graph.getParentIds(chain[0]);
      parentNodes.forEach((parentNode) => chainsToTraverse.push([parentNode, ...chain]));
    }
    return dependencyChains;
  }

  public getEdgesWhereChildIsVulnerable(): VulnerableEdge[] {
    const vulnerableEdges: VulnerableEdge[] = [];

    this.vulnerableNodeIds.forEach((childId) => {
      const node = this.nodeIdToNode.get(childId);
      if (!node) {
        return;
      }
      const vulnerabilityIds = node.release.package.affected_by_vulnerability.map((v) => v.vulnerability.id);
      this.graph.getNodeEdgeIds(childId).forEach((edgeId) => {
        vulnerableEdges.push({
          edgeId,
          vulnerabilityIds,
        });
      });
    });
    return vulnerableEdges;
  }

  private getVulnerableDependencyChainsForNode(nodeId: string): DependencyChain[] {
    const chainIdStrings = this.allDependencyPathsToNodeId(nodeId);

    return chainIdStrings.map((chain) => {
      return chain
        .map((nodeId): VulnerableManifestNode | null => {
          const node = this.nodeIdToNode.get(nodeId);
          const reachability = this.nodeReachability.get(nodeId);
          if (!node) {
            return null;
          }
          return {
            ...node,
            reachable: reachability?.reachable || Analysis_Finding_Type_Enum.Unknown,
            locations: reachability?.locations || [],
          };
        })
        .filter(notEmpty);
    });
  }

  private createDependencyChainPreview(chains: DependencyChain[]) {
    const dependencyChains: DependencyChain[] = [];

    let addedReachableChain = false;
    let addedNotReachableChain = false;
    for (const chain of chains) {
      const isNotReachableChain = !chain.some((d) => d.reachable === Analysis_Finding_Type_Enum.NotVulnerable);

      /*
       If we only want to show a single chain to demonstrate the capabilities of the dependency chain view, then we
        1) determine if the chain is reachable or not
        2) see if we have added a reachable/non-reachable chain already
        3) once we have added both types of chains, then return to save some time
       */
      if (isNotReachableChain && !addedNotReachableChain) {
        dependencyChains.push(chain);
        addedNotReachableChain = true;
      }
      if (!isNotReachableChain && !addedReachableChain) {
        dependencyChains.push(chain);
        addedReachableChain = true;
      }
      if (addedReachableChain && addedNotReachableChain) {
        return dependencyChains;
      }
    }
    return dependencyChains;
  }

  public getVulnerableReleases(): VulnerableRelease[] {
    const vulnerableReleasesById: Map<string, VulnerableRelease> = new Map();

    this.vulnerableNodeIds.forEach((nodeId) => {
      const node = this.nodeIdToNode.get(nodeId);
      if (!node) {
        return;
      }

      const packageReleaseVulnerabilities = this.packageReleaseIdToTriagedVulnerabilities.get(node.release_id);
      if (!packageReleaseVulnerabilities) {
        return;
      }

      const triviallyUpdatable = isReleaseTriviallyUpdateable(packageReleaseVulnerabilities);

      const chains = this.getVulnerableDependencyChainsForNode(nodeId);

      const devOnly = chains.every((chain) => {
        if (chain.length === 0) {
          return false;
        }

        const topDep = chain[1];
        return topDep.labels && topDep.labels.scope === 'dev';
      });

      // loop each vulnerability on the node
      packageReleaseVulnerabilities.forEach((releaseVulnerability, index) => {
        const existingRelease = vulnerableReleasesById.get(node.release_id);
        const isFirstVulnOnNode = index === 0;

        const upsertedRelease = createOrMergeVulnerablePackageRelease(
          existingRelease,
          node.release,
          releaseVulnerability,
          isFirstVulnOnNode ? chains : [],
          devOnly,
          triviallyUpdatable
        );
        vulnerableReleasesById.set(node.release_id, upsertedRelease);
      });
    });
    // now that all vulnerable release chains are populated, go through and adjust the releases
    vulnerableReleasesById.forEach((vulnRelease) => {
      adjustRelease(vulnRelease, this.folderSettings, this.minimumSeverity);
    });
    return Array.from(vulnerableReleasesById.values());
  }
}
